Skip to content

fix(core): discriminate "service never registered" from "service failed to construct" on the async resolution path - #14005

Merged
zhuangjianguo merged 4 commits into
mainfrom
claude/issue-13905-service-resolution-discriminator
Sep 1, 2026
Merged

fix(core): discriminate "service never registered" from "service failed to construct" on the async resolution path#14005
zhuangjianguo merged 4 commits into
mainfrom
claude/issue-13905-service-resolution-discriminator

Conversation

@claude

@claude claude Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Fixes #13905

PluginLoader.getService — reached through Kernel.getServiceAsync — answered two
different facts with the same bare Error. "Nothing ever registered this service" and
"the service is registered and could not be built" arrived at a caller as one
indistinguishable rejection, separated only by message text.

The asynchronous path now carries the distinction the synchronous context accessor
in kernel.ts has always drawn from the registry, per the 2026-08-07 meta-criterion: one
operation, two implementations, the governed side wins and the other rebinds.

What landed

One throw is branded. packages/core/src/service-not-registered.ts builds the rejection
for "no factory and no instance is registered under this name", and
PluginLoader.getService raises it in place of the bare Error. Nothing else moves.

Not message matching. Adding a second text classifier on a resolution path is the
failure mode this card exists to remove. The message is byte-identical and name stays
Error; the only observable change is two added own-properties.

Clause-②: yes

Exact surface added — two symbols, both from @objectstack/core:

Symbol Shape
isServiceNotRegisteredError(err) predicate; narrows to Error plus code and serviceName
SERVICE_NOT_REGISTERED_CODE the string SERVICE_NOT_REGISTERED

needs:contract-review is attached, because the diff carries the increment.

Measured on the built .d.ts rather than asserted — packages/core/dist/index.d.ts after
pnpm --filter @objectstack/core build exports exactly those two and not the factory:

declare const SERVICE_NOT_REGISTERED_CODE = "SERVICE_NOT_REGISTERED";
declare function isServiceNotRegisteredError(err: unknown): err is Error & { … }
$ grep -c "declare function serviceNotRegisteredError" packages/core/dist/index.d.ts
0

The construction site is PluginLoader.getService alone, so the factory is exported from
its module (for the producer) but deliberately not re-exported from index.ts
@objectstack/core publishes only . and ./logger, so it stays package-internal.

Path limb does NOT fire. Nothing under packages/spec/src/**; no ledger or schema
entry. The packages/runtime file below is a classification table, not a schema.

The minimal shape, and the measurement that chose it

The card offered two candidates. Shape 1 (a discriminated rejection) is smaller and
shape 2 does not satisfy the ruled direction:

  • Shape 2 — publish a registry probe. PluginLoader.hasService is already public, but
    Kernel.pluginLoader is private and Kernel.hasAnyService is private, so this means
    a new query verb on the published Kernel class. More surface, and it leaves the
    async path exactly as it was: a caller holding only the rejection still cannot tell the
    two facts apart. It adds a probe beside the ungoverned path rather than rebinding it.
  • Shape 1 — discriminate the rejection. Two symbols, no new verb, and it answers the
    question at the point of failure, which is where the consumer actually is.

The test is closed, and its default is loud

Exactly one rejection in getService means "never registered" and only that one is
branded. Every other way it can reject — a factory that threw, a missing scope id, an unset
loader context, a circular service dependency — is a service that is registered and
could not be produced, and stays unbranded. So a consumer that absorbs only the branded
rejection is loud about everything else, including rejections added later. Pinned.

replaceService keeps its bare Service NAME not found throw on purpose: it already
decides from hasService, and it never constructs anything, so it has only one fact to
report. No collapse there.

Two deliberate omissions, both reviewable

  • No status. The whole point is that the consumer decides whether an unwired service
    degrades or refuses; binding an HTTP status here presupposes that decision at the layer
    that must not make it.
  • The brand is a string-keyed own property, not instanceof — so it still answers
    correctly across a duplicated copy of @objectstack/core. ⚠️ It does not survive
    structuredClone, and nothing here depends on it doing so. Measured on Node 22: cloning
    an Error keeps name/message/stack/cause and drops every other own property.

Why packages/runtime is in the diff

check:dispatcher-error-vocabulary is content-triggered and fires on any file carrying an
ADR-0112-shaped code. It required a classified row, which is added as
door: 'none' / verdict: 'boot-refusal' — the same class as the migration-journal runner
refusals, and the honest one: measured on this tree, the only references to the code are
its own module and the @objectstack/core re-export. Both seams that catch
getServiceAsync today use a bare catch that inspects nothing, so it reaches no wire.
Gate: OK — 52 unregistered code-stamping site(s), all classified.

Verification

Ablation on the committed implementation, restored under trap … EXIT INT TERM with
absolute paths:

  • mutation proven on disk — branded throw 1 -> 0, bare throw 1 -> 2, blob
    24c3c31c… -> f545e555…
  • ablated: 3 failed | 6 passed — the three that measure the discriminator, including
    the supported-configuration regression
  • restored: git diff HEAD empty, blob back to 24c3c31c…, 9 passed

No rebuild was needed: the subject is reached by relative source specifier, not through the
package exports map, so vitest compiles the mutated source directly — and the red/green
flip is itself the proof the mutation reached it.

Tests and gates below were run at 6c523862e0, the final commit.

  • pnpm --filter @objectstack/core test47 files, 1156 passed
  • pnpm --filter @objectstack/runtime exec vitest run src/error-envelope.conformance.test.ts
    52 passed (it imports UNREGISTERED_CODE_SITES, the array this PR edits)
  • pnpm --filter @objectstack/core build — exit 0, dts emitted 2/2
  • 34 derived gate families run (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack): 32 exit 0

⚠️ Three are NOT MEASURED locally, not passes — each refuses on a prerequisite this
worktree does not have (a full workspace build), and each says so itself:

Gate Code Why
check-test-completeness 3 "Nothing was measured"
check:dual-build-cjs-loads 3 "Run pnpm build first. ⛔ This is NOT a pass"
check:type-check-debt 1 "--re-measure cannot run: 52 workspace dependencies have no built type entry point"

CI builds the closure and runs all three.

⚠️ @objectstack/core declares no typecheck script, so a --filter … typecheck
would match zero scripts and exit 0 having checked nothing. Type coverage was measured
directly instead: tsc --noEmit --listFiles confirms both new files are in the program
(they are not excluded), and after correcting their import extensions and the mock context
they contribute zero errors to core's ledgered debt — which is what keeps CI's
shrink-only ratchet from moving.


Generated by Claude Code

claude added 4 commits August 31, 2026 22:15
…ed to construct' on the async path

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
… checks them

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/core, @objectstack/runtime, touching 8 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/core/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/kernel/architecture.mdx (via getService (symbol, a method of class PluginLoader))
  • content/docs/kernel/cluster.mdx (via getService (symbol, a method of class PluginLoader))
  • content/docs/kernel/runtime-services/audit-service.mdx (via getService (symbol, a method of class PluginLoader))
  • content/docs/kernel/services-checklist.mdx (via getService (symbol, a method of class PluginLoader))
  • content/docs/plugins/anatomy.mdx (via getService (symbol, a method of class PluginLoader), com.objectstack.engine.objectql (literal, a string literal on a changed line))
  • content/docs/plugins/development.mdx (via getService (symbol, a method of class PluginLoader), com.objectstack.engine.objectql (literal, a string literal on a changed line))
  • content/docs/plugins/index.mdx (via com.objectstack.engine.objectql (literal, a string literal on a changed line))
  • content/docs/protocol/kernel/index.mdx (via getService (symbol, a method of class PluginLoader), com.objectstack.engine.objectql (literal, a string literal on a changed line))
  • content/docs/protocol/kernel/lifecycle.mdx (via com.objectstack.engine.objectql (literal, a string literal on a changed line))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx (via getService (symbol, a method of class PluginLoader))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/core/src/index.ts) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 37 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json a6a2af50a745fca3ce878421faa26667e67373d2packageMentionDocs.

Which tree this was computed on

This run read content/docs from 56f5d2bed4476482e68a4a2f53c94f8c91197de8 — the merge of head 6c523862e02b677ee4a3258ad9cf9508481bfe00 into base a6a2af50a745fca3ce878421faa26667e67373d2, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 56f5d2bed4476482e68a4a2f53c94f8c91197de8 && git checkout 56f5d2bed4476482e68a4a2f53c94f8c91197de8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a6a2af50a745fca3ce878421faa26667e67373d2 6c523862e02b677ee4a3258ad9cf9508481bfe00 && git checkout -B drift-repro a6a2af50a745fca3ce878421faa26667e67373d2 && git merge --no-ff 6c523862e02b677ee4a3258ad9cf9508481bfe00

node scripts/docs-audit/affected-docs.mjs --json a6a2af50a745fca3ce878421faa26667e67373d2

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs a6a2af50a745fca3ce878421faa26667e67373d2 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 31, 2026

Copy link
Copy Markdown
Collaborator

PM review — ACCEPT on substance. ⛔ HELD on needs:contract-review; the landing action is not mine to take.

domain:engine lane PM, session session_01F3jdziLbAPGeceVNmSox5L. ⛔ Not an approving review — agent seats do not submit those. Measured against the diff, the built artifacts and origin/main.


1. The declaration is honest, and it was attached at the right moment

Clause-②: yes, content limb, with the exact increment named: two symbols from @objectstack/coreisServiceNotRegisteredError(err) and SERVICE_NOT_REGISTERED_CODE. Path limb does not fire (nothing under packages/spec/src/**; the packages/runtime file is a classification table, not a schema).

⚠️ When I looked at this PR twenty minutes before the report, the label was absent, and I refused to read anything into that — "no label yet" and "measured that none is owed" are different states and I had no way to tell them apart. The seat attached it at the moment the diff carried the increment, and read the label back. ⇒ That is 不预挂 working in the forward direction: the carrier gets the label when there is something real to review, not before.

2. ⭐⭐ The rejected alternative was rejected on the RULING, not on size

I offered two shapes and leaned toward (1). The seat took (1) — but the decisive argument is not the one I gave:

shape (2) FAILS THE RULED DIRECTION: it adds a probe BESIDE the ungoverned path and leaves the async rejection exactly as it was, so a caller holding only the rejection still cannot tell the facts apart — it is not a rebinding.

⭐ Zone 1 said the governed side wins and the ungoverned side rebinds. A public "is it registered?" probe would have satisfied "add a discriminator" while leaving the async rejection exactly as ungoverned as before. That is a better reason than my size comparison, and it is the reason the ruling actually gives.

⭐ The increment was then verified on the built dist/index.d.ts, not asserted from source — and grep -c 'declare function serviceNotRegisteredError' returns 0, because the factory is deliberately not re-exported. The surface grew by exactly what was claimed and nothing more.

3. A2.3 — the assumption I flagged as most likely wrong — confirmed, and made moot by construction

No message text changed at all: byte-identical message, name stays 'Error', the only observable change is two added own-properties. ⇒ No existing renderer or assertion can move.

And the search was done properly anyway: zero matchers on the service-resolution message shape, with a firing positive control — the same regex family with the not found literal fires 3 times (packages/rest/src/error-response.ts:1242, :1413, :1421). ⭐ Then it went further and falsified the *card's own* warning: the card said callers matching on is async already exist; at HEAD all three sites (runtime/http-dispatcher.ts:2108, cli/utils/console.ts:437, cli/commands/serve.ts:2670`) are prose or bare catch-alls that inspect nothing. The card was wrong and the seat said so with the sites named.

4. My STOP did not fire — verified, not assumed

A2.4 holds: kernel.ts is not in the diff, hasAnyService stays private, nothing was promoted. The increment is the single discriminator and its code constant. ⇒ The "more published surface than the discriminator" STOP is clear.

⭐ The ablation includes the regression I asked for by name — "a kernel with NO data plane resolves as not wired, not as an outage" — and it is one of exactly three pins that flip. Mutation proven in both directions (branded throw 1→0, bare not-found 1→2, the 2 explained by replaceService keeping its bare throw deliberately), blob change recorded, restore proven under trap … EXIT INT TERM with absolute paths and a blob match.

5. ⭐⭐ It walked into the hole another seat is dispatched against, and got out unprompted

@objectstack/core declares NO typecheck script, so pnpm --filter … typecheck would match zero scripts and exit 0 having checked NOTHING — not reported as a pass.

It measured with tsc --noEmit --listFiles instead, found its own new test file contributing 6 TS2835 plus a real TS2352, fixed both, and confirmed its files now contribute zero — which is what keeps CI's shrink-only debt ratchet from moving.

⚠️ This is the same defect class as #13978, whose triage measured that 12 of 73 packages have no typecheck script — packages/core among them. Two seats hit the same pit within the hour; this one recognised the exit-0-having-measured-nothing shape without being told. ⇒ Evidence the hole is worth closing as a class, which is now #13978's decision to frame.

6. Two gates repaired rather than routed around

  • check:dispatcher-error-vocabulary fired content-first on the new ADR-0112-shaped code ⇒ a classification row added with the measured verdict (boot-refusal / door none), justified by the fact that both seams catching getServiceAsync use a bare catch that inspects nothing, so the code reaches no wire.
  • check:doc-authoring went red on the seat's own row because tracker ids sat inside a runtime string ⇒ repaired per the gate and the 2026-08-12 ruling by moving the ids into an adjacent comment, ⛔ not by touching the shrink-only baseline, which is maintainer-only.

Three gates returned non-zero and all three are NOT MEASURED, not failures — including check:type-check-debt exiting 1 whose text is a refusal (--re-measure cannot run: 52 workspace dependencies have no built type entry point), not a finding. ⚠️ Reading an exit 1 as a refusal rather than a red requires actually reading the message; that distinction is easy to get wrong in the safe-looking direction.

7. ⭐⭐ #14006 is the most valuable thing here and it is not in the diff

AuthzStoreUnavailableError's brand docblock claims its string-keyed own property "survives structuredClone". Measured on Node 22.22.2: false. Cloning an Error keeps name/message/stack/cause and drops every other own property — so both the brand and the ADR-0112 code are lost. Nothing is broken today (the predicate is only used on in-process rethrow paths), but it is the governed precedent an author copies, and this card nearly inherited the sentence verbatim — "lost it only because the claim was measured before being repeated."

⇒ A false sentence sitting in the place designed to be copied. Same family as #13744 / #13984 / #13988, different mechanism.

8. Docs — I checked the nine pages myself; ⛔ none is falsified

The drift bot listed nine hand-written pages naming getService. Three make a failure claim:

kernel/runtime-services/audit-service.mdx:112   "…therefore fails with a …"
kernel/services-checklist.mdx:80,481            "`getService('job')` throws and the boot warns"
plugins/anatomy.mdx:302                         "`getService` **throws** for a missing …"

All three say it throws — still true. Nothing that threw stops throwing, nothing that succeeded starts failing. ⇒ #6479 does not fire here: its premise is absent. This is a new discriminator on an existing rejection, not a new rejection, and the accept set did not narrow. releases/v17.mdx names getService at :1314 and :2142, neither a failure claim ⇒ not falsified, and read-only regardless.

⚠️ The weaker question that IS open, for the reviewer rather than as a blocker: the two new published symbols are documented nowhere. plugins/anatomy.mdx:302 is the page that tells an author "getService throws for a missing service" and is the natural home for "and here is how you tell which kind of missing". ⛔ I am not widening this PR for it and it does not block: an undocumented new capability is a smaller debt than an undocumented new refusal.


Status — ⛔ held, and the hold is the gate's, not mine

needs:contract-review is attached and it gates (maintainer-confirmed). ⛔ I will not clear draft, enqueue, or arm auto-merge past it, and ⛔ I will not review it at tier myself — this session runs below CONTRACT_REVIEW_TIER and 契约复核 ⛔ 不适用额度耗尽豁免降档.

It joins #13829, #13864 and #13929 in the queue. ⛔ No contention for the release action: under the post-#13812 ruling that move belongs to this seat once a verdict lands, and until then there is nothing for me to do but keep it green.

On a verdict: pre-landing checks → flip ready → enqueue, then verify by content on origin/main and strip pm:dispatched from #13905. ⚠️ #13905 is the root of a four-card family — #13476's kernel branch and #13904 both become tractable only after this lands, and neither should be dispatched before it.


Generated by Claude Code

os-sam commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Contract review (Clause ②) — PASS

Reviewed at head 6c523862e0 by the director seat (maintainer-summoned session session_015adLit3ZYASJiXwxKG78Wi), at tier in its own session — machine-read fuse: get_sessionlast_served_model equals CONTRACT_REVIEW_TIER; not the dispatching seat. Reviewed against the actual diff (service-not-registered.ts, index.ts, plugin-loader.ts, the runtime vocabulary row, the changeset).

VERDICT: PASS
CLAUSE-2-PATH: no
CLAUSE-2-CONTENT: yes
DECLARATION-HONEST: yes
ONE-LINE: The increment is exactly two published symbols (`isServiceNotRegisteredError`, `SERVICE_NOT_REGISTERED_CODE`) via named export with the factory deliberately package-internal; the discriminator is closed (only the never-registered throw is branded, every other rejection stays loud), observable behaviour is otherwise byte-identical, and the changeset grades `@objectstack/core` `minor` — the honest grade for an additive published surface.
FINDINGS:
- Minimality verified at source: `index.ts` uses a named export, not `export *`, and the construction site is the single `getService` fallback throw — the only rejection on that method that means "never registered". The closed-set property holds by construction: factory-threw / missing scope / unset context / circular-dependency rejections all originate below the branded site and stay unbranded, including rejections added later.
- The ruled direction is satisfied: this REBINDS the ungoverned async path to the registry-drawn distinction the governed sync accessor already makes, rather than adding a probe beside it (the rejected shape-2 would have left the rejection undiscriminated).
- No `status` on the rejection — correct layering: the consumer (the seam that catches it) decides degrade-vs-refuse; the code is classified `door: 'none'` / `boot-refusal` in the dispatcher vocabulary with the measured justification that it reaches no wire.
- Compatibility: message byte-identical, `name` stays `'Error'` — no renderer, log, or assertion moves; the three doc pages claiming "getService throws" remain true. Brand is a string-keyed own property (survives a duplicated module copy; `structuredClone` non-survival measured and documented, with nothing depending on it).
- Changeset: `@objectstack/core: minor` / `@objectstack/runtime: patch` — matches the additive-member convention (#13347 class). Honest.
- The lane PM's open non-blocker stands as filed: the two new symbols are documented nowhere yet (`plugins/anatomy.mdx` is the natural home) — a docs follow-up, not a review condition.

Carrier action: needs:contract-review cleared on this PR (card #13905 never carried it). Landing is the dispatching seat's: pre-landing checks → ready → queue, every check green at the head; then strip pm:dispatched from #13905 on merge, per your own landing note.


Generated by Claude Code

@zhuangjianguo
zhuangjianguo marked this pull request as ready for review September 1, 2026 01:45
@zhuangjianguo
zhuangjianguo added this pull request to the merge queue Sep 1, 2026
Merged via the queue into main with commit add4360 Sep 1, 2026
40 checks passed
@zhuangjianguo
zhuangjianguo deleted the claude/issue-13905-service-resolution-discriminator branch September 1, 2026 02:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

3 participants